← 返回文章列表

从零构建 AI Agent(四):当 Agent 开始面对 Scale

写完前三篇,我以为下一步是部署或多 Agent 协作,结果被一个更底层的问题拦住——我的 Agent 开始加不动新东西了。这篇写的是两件同时发生的事:对接 agentskills.io 开放协议让 Agent 突破"能力天花板"(能力的 Scale),以及一次被 190 行主函数逼出来的深度重构(代码的 Scale)。过程中做了 Skills 的五模块设计、调研了 Claude Code / OpenClaw / Hermes 三家的工程决策、抽出了一个叫 Session 的隐藏对象、修了 6 个原本就存在的 bug。核心认知:扩展性不是往外加东西,首先是代码得能承受你还要加东西

agent
skills
scale

作者: Jinkun
时间: 2026年4月
项目地址: github.com/yaoziyaoguai/my-first-agent
前置阅读:


前三篇写的是"怎么让 Agent 能做一件事"——一个工具、一段 prompt、一个记忆系统。写到第四篇我本来打算接着做"部署到云"或者"多 Agent 协作"。没做成。因为一个更底层的问题把我拦住了——

我的 Agent 开始加不动新东西了。

每加一个"专业能力",我就要写一套工具注册、一段 prompt、一个新函数。加到第八个的时候我改 core.py,翻到一半忘了前面在说什么。从头看第二遍,看到一个变量 tool_call_count,我盯着它想了五分钟——这东西到底是数什么的?是每次循环加一,还是每次工具调用加一?我自己两周前写的代码,自己看不懂了。

这种时候一般我的反应是"我今天状态不好"。但刚休息过,我突然意识到不是状态问题。

是代码在跟我对着干。

这篇写的就是这件事——我怎么撞上 Scale 这道墙、怎么通过对接 agentskills.io 生态来解决"能力的 Scale"、以及怎么通过一次深度重构解决"代码的 Scale"。两件事同时发生、互相推动。写完我才明白,这可能是 Agent 开发里最重要但最少被提到的一个转折点。


一、Scale 是什么——而且它不是性能

先把话说清楚。前几篇博客我也用过"扩展"这个词,但都是模糊的。今天这篇里的 Scale 是一个很具体的概念。

Scale ≠ 性能,≠ 并发,≠ 能跑多少用户。

Scale 是一个更工程的问题——当你要往系统里持续加东西的时候,系统有没有办法让你加得动。

想一下你家里装书。第一本书你随便放——沙发、床头柜、厨房桌子都行。第十本书你开始需要一个书架。第一百本书你需要分类、需要索引。第一千本你需要图书馆。每个阶段的"装书"做法都不一样,而且前一阶段的做法在后一阶段会崩。

代码系统面对的是一样的问题。

写第一个工具的时候,你直接在 main.py 里定义一个函数、在 prompt 里描述它、在 Agent Loop 里加个 if 分支判断工具类型——能跑。到第三个工具你觉得 if/elif 不太优雅,抽出来一个 dispatch 函数。到第七个你发现每个工具都有自己的确认规则、安全检查、log——你做了插件化注册(我 第二篇博客里写的那个)。

然后你想到第八个能力的时候出事了。

这个能力不是工具——它是一组"做法"。比如"写技术博客的时候要用第一人称学习者视角,要带踩坑清单,结尾要有认知转变"。这不是一个函数能解决的问题,这是一段指令。我把它塞 system prompt 里吗?那下一个做法("代码审查的时候要先看架构再看细节")也塞吗?第五个、第十个、第五十个呢?system prompt 要写成两万字的大杂烩吗?

这就是我撞上的第一面墙——能力 Scale。一个一个内嵌是不可能的,这条路走不通。


二、对接生态:第一个 Scale 解法

这个问题有一个现成的答案,2025 年 12 月,Anthropic 把 Claude Code 里用的 skill 格式开源,变成了一个叫 agentskills.io 的开放协议。到 2026 年 4 月,Claude Code、OpenAI Codex、Gemini CLI、GitHub Copilot、Cursor、OpenClaw、Hermes 等 26+ 个 Agent 产品都支持同一套 SKILL.md 格式。

这个格式本身很简单——一个目录、一个 SKILL.md 文件、YAML frontmatter + Markdown body 描述"这个能力叫什么、什么时候用、怎么做"。但真正重要的不是格式,是这个格式背后的生态。

ClawHub(OpenClaw 的 skill 中心)有 3200+ 个 skill。tonsofskills.com(Claude Code 的市场)有 4200+ 个 skill 和 2849 个专业 agent。每个都能装到你的 Agent 里,即插即用。

这才叫 Scale。 我不可能自己造 4200 个能力。但一个社区能。一个协议能让这些能力在不同 Agent 之间流动。

所以做 Skills 对我来说从一开始就不是选择题。要做 Agent,就必须对接这个协议——否则无法融入整个生态,怎么可能让agent能力大幅度扩展?

我花了一两个小时调研 Claude Code、OpenClaw、Hermes 三家的具体实现。初看以为都差不多,后来发现他们在三个关键点上分叉:

第一,注入位置。Claude Code 和 OpenClaw 把 skill 内容注入 system prompt。Hermes 注入 user message。这个差异看起来微不足道,但背后是真金白银——Anthropic 的 API 支持 prompt caching,cache 命中时 input token 成本是 1/10。Hermes 文档里说他们典型 session 里 90% 的 token 都是 cache 读。

system prompt 注入方案有一个致命问题——skill 是动态加载的,用户说"写博客"才加载 blog-writing。每次加载都会改 system prompt,cache 作废。成本 10 倍地涨。

Hermes 的设计就是为了避开这个——system prompt 只放 skill 元数据(名字 + 描述),从头到尾不变;skill 的完整内容作为用户消息出现,加进对话里。cache 始终有效。

我自己的项目目前没启用 Kimi 的 context caching,所以 第一步 我先走 system prompt 方案。等启用 cache 再升级。这不是偷懒,是工程顺序——先让它工作,再让它高效。

第二,安装来源。Claude Code 走 app store 模式(必须先 /plugin marketplace add 添加市场,再 /plugin install 安装)。OpenClaw 一步搞定(clawhub install <slug>)但需要通过中央 registry。Hermes 最开放——直接给 GitHub URL 就能装。

第三,安全扫描。OpenClaw 扔给 VirusTotal,Hermes 自己写了 65+ 条规则的本地扫描器(8 大类:数据外泄、prompt injection、破坏性命令、代码混淆、硬编码密钥、网络滥用、环境变量滥用、供应链),Claude Code 走人工审核。

这三个差异没有对错——每家都在不同的点上做了不同取舍。但让我意识到一件事——对接生态不是照抄协议那么简单,协议之下还有一层工程决策。我自己的项目虽然小,但这些决策一样要做。


三、自己的 Skills 系统:五个模块的分工

做下来的结构是五个模块:

agent/skills/
├── parser.py       把 SKILL.md 变成 Python dict
├── safety.py       对 skill 做安全扫描
├── registry.py     管理所有已加载的 skill
├── loader.py       模型调用时,返回 skill 的完整内容
└── installer.py    从 GitHub 下载新 skill

分五个模块不是随便分的。判断标准是 Parnas 1972 那篇论文说的那句话——模块的边界应该是"会一起变化"的代码块。

Parser 会变,如果协议升级(比如 agentskills.io 2.0 出新字段)。 Safety 会变,如果发现新的攻击模式。 Registry 会变,如果存储方式换了(比如从文件改 SQLite)。 Loader 会变,如果从 system prompt 注入改成 user message 注入。 Installer 会变,如果支持新来源。

每一个变化的原因都不一样。 分开就意味着每一次变化被隔离在一个模块里。Registry 换存储方式,Parser / Safety / Loader / Installer 都不用动。

有一个很实际的测试——我最早想把 Parser 塞进 Registry 里("反正只有 Registry 用它")。后来想清楚了不行——Installer 下载完 skill 后,要先用 Parser 验证格式合法,才能继续安装。如果 Parser 绑死在 Registry 里,Installer 就要硬依赖整个 Registry 才能做一个简单验证。

这种"不能绑"的感觉,不是理论推导的,是被一个具体场景逼出来的。

这次做 Skills 的过程里,Parnas 那篇论文从"学懂"变成"用出来"。以前读它感觉抽象,这次是第一次在自己的代码里认出它在说什么。


四、两个值得单独提的工程决策

Skills 系统里有两个决策我觉得特别值得讲,因为它们不是照着协议抄的,是自己撞出来的。

决策一:memory 和 skills 不能互相知道

做 Registry 的时候我遇到个问题——skill 清单要怎么进 system prompt?

我原本的代码有个函数 build_memory_prompt(),把 memory 里的 profile、rules、episodes 拼成一段 system prompt 片段。第一反应是"顺手在这里也加上 skills"。

动手写到一半我停了。感觉怪怪的。

memory 是"这个 Agent 的个人积累"——profile 是用户信息、rules 是行为习惯、episodes 是历史对话摘要。skills 是完全不同的东西——它是可移植的外部能力包,可以从社区下载、跨 Agent 使用。

把 skills 塞 memory 模块里,就是所谓的便利性耦合——"反正都在拼 prompt,顺手加吧"。但这两个概念根本不是一家。

我干脆新开了一个模块叫 prompt_builder,它的职责只有一个——组装 system prompt。它调 build_memory_section() 拿 memory 的部分,调 build_skills_section() 拿 skills 的部分,然后拼起来。memory 和 skills 两个模块根本不知道对方存在。

那个"感觉怪怪的"是架构嗅觉。不是随便的感觉,它是在告诉你——有一个对象应该被拎出来,但现在被塞在不合适的地方。

决策二:事务性安装

Installer 做下载和安装的时候最容易出事——网络断了、下载下来的不是合法 skill、安全扫描拒绝、复制失败——任何一步出问题,你的 skills/ 目录都可能留下一个半死不活的 skill。

我用的模式是 staging + commit——把下载先放到临时目录,所有验证都通过才复制到正式位置:

临时目录:
    git clone 下来
    定位子目录
    Parser 验证
    Safety 扫描
    全部通过?
         ↓
正式目录:才真的写入

任何一步失败,临时目录自动清理,正式目录完全没动过。

这个模式在数据库里叫事务(BEGIN / COMMIT / ROLLBACK),在 Kubernetes 里叫 staged rollout。原理都是——操作要么全做完,要么完全没发生过。

更新 skill 也是一样——先备份当前 skill 到临时位置,删除旧的,重新下载,失败就从备份恢复。用户的 skill 不应该因为"想更新"反而被弄坏。

这两个决策都不是协议要求的,是自己在做的过程里摸出来的。现在回头看——对接一个生态协议,最难的不是实现协议本身,是协议之外那些它没规定的地方。


五、撞墙:做 Skills 时把 core.py 搞出了第二种 Scale 问题

Skills 系统做到一半,我开始改 core.py。

core.py 里有个叫 chat 的函数,是整个 Agent 的主循环。我要在里面集成 skills——system prompt 要加 skills section、工具列表要加 load_skill、一些分支逻辑要变。

打开文件。翻到 chat 函数。

190 行。

我翻到第 120 行,想起来前面有个变量叫 round_tool_traces,回去找——哦对,在第 60 行定义的。往下翻,发现第 85 行又定义了一遍。两次?为什么两次?

再往下——第 100 行有个 tool_call_count += 1。这个在循环最开始加的,但循环有时候因为 max_tokens 自动继续会走一轮(没调用任何工具),计数还是会 +1。所以这个变量名字是"工具调用数",实际数的是"循环数"。

找到变量 consecutive_rejections。第 180 行判断 >= 2 的时候追加了一段系统指令——但这个判断在外层 if/else 分支之外,意味着approved is True(正常执行)的情况下也会追加。这不对。

我看了十分钟,发现写的三个隐藏 bug。都不是新写的,都是过去两周里打补丁时埋下的。

这就是 Scale 的第二种形式——代码的 Scale。

每一次加新功能,我的做法是——在 chat 函数里找一个"差不多的地方",加一段代码。第一次加 max_tokens 重试逻辑,塞在循环开头。第二次加 review 自动重试,塞在 end_turn 分支里。第三次加连续拒绝强制停止,塞在 tool_use 分支的 else 里。每次加的时候都很自然——反正这段逻辑和这里相关,塞进去就完了。

每一次都是局部最优。但累积起来,就是一个没人能一次读懂的 190 行函数。

我这时候才明白,前一天下午效率特别低不是偶然的。不是我累了、状态不好。是我的工作记忆装不下这个函数了。每看一次我都要重建一次心智模型——这个变量是干什么的、这个分支什么时候走、这段代码为什么在这里。每次重建都消耗注意力。

Scale 应用到代码上就是——代码有没有办法让你加得动。而 chat 函数这种结构,它已经加不动了。


六、重构:把 Session 作为一个对象看出来

重构的第一个问题不是"怎么拆",是"往哪拆"。

我盯着 main.py 看了很久。这个文件也有 150 行,里面散着各种逻辑:

init_memory()
cleanup_old_episodes()
run_health_check()

checkpoint = load_checkpoint()
if checkpoint:
    # 询问用户是否恢复
    ...

while True:
    try:
        user_input = input("你: ")
        if user_input == "quit":
            # 提取记忆
            # 保存快照
            # 保存 checkpoint
            break
        chat(user_input)
    except KeyboardInterrupt:
        # 连续两次 Ctrl+C 处理
        # 有 checkpoint 的菜单
        # 没 checkpoint 的提示

我盯着看,突然看出来——这里面藏着一个东西。

那些 init_memory + cleanup + health_check,是 session 启动。 那些 load_checkpoint + 询问恢复,是 session 恢复。 那些 extract_memories + save_snapshot + save_checkpoint,是 session 退出。 那些 KeyboardInterrupt 处理,是 session 中断。

全是 session 生命周期的代码。但没人把它叫"session"。它以七八段散代码的形式存在于 main.py 里,每一段各自合理,合起来就是一团乱麻。

这就是"对象"隐藏的方式。

它不是数据结构。它是一段有自己生命周期的事件。识别出来它才存在;识别不出来,它就以碎片形式继续消耗你的理解力。

我抽出一个新模块 agent/session.py:

init_session()
try_resume_from_checkpoint()
finalize_session()
handle_interrupt_with_checkpoint()
handle_interrupt_without_checkpoint()
handle_double_interrupt()

六个函数都在讲同一件事——session 的生命周期。

重构之后 main.py 从 150 行变成 50 行,而且每一行都在讲同一件事:程序入口的编排。

if __name__ == "__main__":
    init_session()
    try_resume_from_checkpoint()
    main_loop()

就这样。其余全部外包给 session.py。


七、重构 chat():从 190 行到 30 行

同样的思路用在 chat()。

那个 190 行的函数,拆开看其实在做 7 件独立的事:

  1. 规划(生成 plan + 确认 + 注入上下文)
  2. 主循环(调模型)
  3. max_tokens 处理(输出被截断的自动继续)
  4. end_turn 处理(模型说完了 + Review)
  5. tool_use 处理(工具调用分发)
  6. 单个工具执行(防循环 + 确认 + 执行)
  7. 自动重试(Review 没通过时)

拆成 8 个函数:

chat()                        主入口,编排(30 行)
├── _run_planning_phase()     规划阶段
└── _run_main_loop()          主循环
    ├── _call_model()              调模型
    ├── _handle_max_tokens()       处理截断
    ├── _handle_end_turn()         处理完成+Review
    └── _handle_tool_use()         处理工具
        └── _execute_single_tool() 执行单个工具

还做了一件事——把原来散落的八九个局部变量打包成一个 dataclass:

@dataclass
class TurnState:
    effective_review_request: str
    system_prompt: str
    round_tool_traces: list = field(default_factory=list)
    recent_calls: list = field(default_factory=list)
    auto_retry_count: int = 0
    tool_call_count: int = 0
    loop_iterations: int = 0
    consecutive_rejections: int = 0
    consecutive_max_tokens: int = 0

这些变量以前全是 chat() 函数里的游离局部变量,互不相干地散着。现在它们有一个共同的名字——TurnState,一次对话轮的状态。每个小函数都接收同一个 state 对象,清清楚楚地知道自己能读写什么。

"对象"又一次从混乱中浮现。 它不是我设计出来的,是我把原本的散乱收拢起来,给它一个名字。


八、重构 ≠ 修 bug:六个原本就在的 bug

重构完我以为搞定了。给这份代码仔细看了一遍的人——是 Claude,和我一起 pair 的 AI——先抓出两个 bug:

Bug 1:tool_call_count 在循环开头 +1,名字说的是"工具调用数",实际数的是"循环数"。max_tokens 自动继续也走循环,也 +1,导致长输出被截断 3 次就吃掉 3 个"工具额度"。

Bug 2:Review 失败达到重试上限后,代码进入 else 分支然后 clear_checkpoint()。但这种情况下任务明明没完成——清了 checkpoint 用户下次就恢复不了了。

我被推着重读代码,又发现了 4 个:

Bug 3:_is_repeated_call 这个函数名是查询,但它内部会 append 到 recent_calls——有副作用。名字骗人。

Bug 4:防循环拦截一个工具调用后,round_tool_traces 没记录这次拦截。Review 看不到。

Bug 5:end_turn 时 assistant_text 可能是空串(模型只返回了 tool_use 块)。返回空串给上层调用者体验差。

Bug 6:plan 确认时用户按回车(空串)会走"修改意见"分支,把空串当修改意见。应该视为同意。

六个 bug。全是原代码就有的。

这件事让我对"重构"这个词的理解变了。

重构 ≠ 修 bug。 我第一轮重构做的是结构整理——拆函数、合并重复、打包变量。这些都是表面结构的修改,不涉及行为语义的审查。代码行为对不对,我没检查。

第二轮被追问"为什么每轮循环都 +1"、"为什么拒绝 review 就清 checkpoint"——这些问题触及的是行为层。我被推着重新审视每个 if/else,才发现埋的雷。

严肃的重构前应该先有测试。我没有,所以只能靠别人的追问来触发行为审查。没有测试的重构,本质上是"信自己"。但原代码就有 bug 是常态,重构只是把 bug 从乱糟糟的结构里挪到了干净的结构里——bug 没少,只是更容易看出来。


九、关于设计模式:一个具体的决策

重构里一个小问题——Registry 应该是单例吗?

Registry 维护着所有已加载 skill 的内存字典和警告列表。如果每次访问都 new 一个新实例,每次都要重新扫文件系统,warning 会重复累积。必须只有一份。

Python 里实现单例最自然的方式不是写 class Singleton——模块级变量 + 工厂函数就够:

_registry: Optional[SkillRegistry] = None

def get_registry() -> SkillRegistry:
    global _registry
    if _registry is None:
        _registry = SkillRegistry()
        _registry.discover_skills()
    return _registry

为什么?因为 Python 模块本身就是单例——一个模块在进程里只 import 一次,模块级变量自然全局唯一。不需要 Java / C++ 那套锁机制。

我选的是"懒加载"版本(get_registry() 第一次被调用时才创建,不是 import 就创建)。因为 Registry 创建成本不低(要读文件系统),懒加载推迟到真正要用的时候,也方便测试时替换。

但同一个文件里还有个函数 build_skills_section()——拼 skill 清单到 system prompt 用的。它不是单例,是纯函数:

def build_skills_section():
    registry = get_registry()
    skills = registry.list_skills()
    # 拼字符串
    return "\n".join(...)

没内部状态,每次调用都从 registry 拿最新数据重新拼。完全不需要单例化。

判断要不要单例的标准很简单:有内部状态 + 创建成本高 → 单例;无状态纯函数 → 不要单例。

Registry 符合前者,build_skills_section 符合后者。它们在同一个文件里,但一个是类,一个是模块级函数。

共处一室不代表是一家人。 这也是 Python 相对 Java 的好处——不用什么都塞进类里。

这一段之所以值得单独拿出来讲,是因为设计模式经常被当成"炫技"——单例、工厂、装饰器、观察者,一堆名词。但模式本身没意义,为什么用这个模式才有意义。我这里用单例不是因为"Registry 听起来该是单例",是因为它有状态 + 创建贵。build_skills_section 不用单例不是因为"函数不能单例",是因为它本来就是纯的。

设计决策的理由比决策本身重要十倍。


十、踩坑清单

这一节是我每篇 blog 都有的——给踩过一样坑的人省时间:

  1. "自己造一套"对 Skills 这种东西完全不成立——有 agentskills.io 生态就对接生态,你不可能和整个社区比手速。

  2. 协议之外的工程决策可能比协议本身重要——注入位置、安装来源、安全扫描,三家做法都不一样,背后是真金白银的权衡。

  3. prompt cache 决定 skill 注入位置——如果用了 cache,skill 不能进 system prompt,要进 user message。

  4. skill 下载不能直接覆盖——staging + commit,事务性安装,任何一步失败都不影响原状态。

  5. "我把它塞进已有的 X.py 里吧"——感觉怪就是在告诉你有耦合了。memory 和 skills 互不知道才是对的。

  6. "我效率低"有时候不是心理状态,是代码质量的指标——一个 190 行函数让你每次读都要重建心智模型,是代码坏了,不是你笨。

  7. 重构 ≠ 修 bug——结构整理不保证行为正确。没有测试的重构是"信自己",但原代码通常就有 bug。

  8. 对象是识别出来的,不是设计出来的——session 和 TurnState 都不是我提前设计好的对象,是我把散乱的代码收拢起来之后浮现的。

  9. 设计模式没意义,用它的理由才有意义——单例不是因为"听起来像单例",是因为有状态+创建成本高。

  10. YAGNI——你以为未来会需要的抽象,90% 不会发生。合并成一个函数,等真的有第二个调用方再拆。


十一、这两件事背后是同一个问题

写完重构那天晚上我躺在床上想,今天做的两件事——对接 Skills 生态、重构 core 和 main——为什么会碰到一起?

后来想明白了。它们是同一个 Scale 问题的两个面。

做 Skills 解决的是 能力 Scale——单个 Agent 的能力数量怎么突破"一个一个内嵌"的上限。协议、生态、渐进披露、事务性安装,这些都是为了让你的 Agent 能承载几千个能力还不崩。

重构 解决的是 代码 Scale——代码本身有没有办法让你继续加东西。拆函数、抽对象(session、TurnState)、用 dataclass 收拢状态,这些都是为了让你三个月后回来改代码还能读懂。

如果只做 Skills 不重构,你的 Agent 对外能装 1000 个 skill,对内 core.py 加几行就崩。 如果只重构不做 Skills,你的代码很漂亮,但能力天花板摆在那里——你自己写不完 1000 个内嵌工具。

两边必须同时推进。 这是这次做下来我最深的体会。以前我一直以为扩展性是往外加东西(加功能、加工具、加模型),现在才明白——扩展性首先是代码得能承受你还要加东西。

前三篇博客里我一直在做"加"——加工具、加记忆、加规划。这一篇是第一次做"收"——把散乱的东西收成对象、把重复的结构抽成模式、把不该耦合的地方拆开。

"收"看起来没产出。跑完测试功能和昨天一样,没多一个工具、没多一个能力。但系统变了——它从"现在能用、以后加不动"变成了"现在能用、以后也加得动"。

这种差别不是跑一个 demo 能看出来的。只有当你未来第 N+1 次想加新东西,你坐下来打开 core.py,十分钟之内就知道往哪加——那时候你才会感谢今天做的重构。


十二、下一步

Skills 的兼容性验证还没做——要用 install_skill 从 github.com/anthropics/skills 拉一个真实 skill,跑通整个生态对接的闭环。这个做完才算 Scale 的第一步真正落地。

然后是——升级到 Hermes 那种 user message 注入方案,配合 Kimi 的 context caching,让成本曲线从"每次加载新 skill 都破 cache"变成"长对话 90% token 是 cache 读"。这是真正把协议的经济性吃透。

再然后就是扩展生态——我想看看 agentskills.io 上的 skill 能不能直接在我的 Agent 上跑。如果能,那协议就真的生效了——我写的代码可以加载别人写的能力。这是开放生态最激动人心的瞬间。


两件事写到这里就收尾了。如果你也在做 Agent,某天突然发现自己开始"加不动"了——先别以为是自己状态不好。看看你的 core 函数有多长。

坏代码不会诚实地告诉你它坏了。它只会让你越来越慢,让你怀疑自己。识别这件事是一种能力,而且可能比你会多少技术都更重要。


源码:github.com/yaoziyaoguai/my-first-agent

系列前三篇:

分享这篇文章

复制链接,或分享到你常用的地方。

评论

发表评论

0 / 1000

KEEP READING

全部文章